Thirty Days of Metal — Day 21: Point Lights

Warren Moore
4 min readMay 1, 2022

This series of posts is my attempt to present the Metal graphics programming framework in small, bite-sized chunks for Swift app developers who haven’t done GPU programming before.

If you want to work through this series in order, start here. To download the sample code for this article, go here.

Directional lights are great for covering large scenes with powerful light from a dominant direction, but apart from the sun, we don’t see many directional lights in the real world. We need another type of light to model light sources that radiate in all directions and can move around the scene. These lights are called points lights, or punctual lights, to emphasize the fact that they have a particular position and emit light radially from that position.

In this article, we will discuss how to update our lighting shaders to consider contributions from points lights. Along the way, we will introduce an important physical phenomenon that affects how light intensity diminishes over distance, called attenuation.

Since we already updated our Light class with a transform that allows us to express a light’s position, we do not need to modify it any further. We will add a new member to our light type enumeration, called .omni, to emphasize that points lights radiate in all directions (i.e., they are omnidirectional).

enum LightType : UInt32 {
case ambient
case directional
case omni
}

We make a similar change to the LightType enum in our shader code as well. To make lighting calculations easier, we also extent our LightConstants structure with a position member, which holds the world-space position of the light:

struct Light {
float4x4 viewProjectionMatrix;
float3 intensity;
float3 position; // world-space position
float3 direction; // view-space direction
LightType type;
};

Attenuation

Directional lights are presumed to be infinitely distant, which implies that their intensity does not decrease (“fall off”) with distance. Point lights, on the other hand, are assumed to be local, which means their intensity should fall off proportional to the squared distance to a surface.

This distance-related falloff is one kind of attenuation, or reduction in intensity. To capture it mathematically, we need to know the distance from the light to the surface. We then multiply the light’s intensity by the reciprocal of the distance squared.

Implementing Attenuation

To implement attenuation in a shader, we first find the vector pointing from the light’s world position to the current fragment’s world position (call it toLight). This vector points in the same direction as the L vector we have used previously, but it is not normalized.

Because we know that the dot product of two vectors is equal to the product of their lengths multiplied by the cosine of the angle between them, and because we know that the angle between a vector and itself is 0, we can find the squared distance to the light by taking the dot product of toLight with itself:

lightDistSq = dot(toLight, toLight)

Dividing 1 by the squared distance gives the attenuation factor, but there is a problem. When the distance goes to zero (i.e., when the light is “on” the surface), the reciprocal goes to infinity. We hack around this by taking the reciprocal of the max of the squared distance and a small value, preventing division by zero.

attenuation = 1 / max(lightDistSq, 1e-4)

We can combine these expressions into a function that yields the distance attenuation factor of a light, given a reference to the light and the vector from the surface to the light:

float distanceAttenuation(constant Light &light, float3 toLight) {
switch (light.type) {
case LightTypeOmnidirectional: {
float lightDistSq = dot(toLight, toLight);
return 1.0f / max(lightDistSq, 1e-4);
break;
}
default:
return 1.0;
}
}

We then implement the rest of the lighting model in a very similar way to our directional implementation. The chief difference is that we normalize the toLight vector to find our light direction, rather than using the light’s own direction vector.

Here is the complete code for our point light lighting implementation:

case LightTypeOmnidirectional: {
float3 toLight = (light.position - in.worldPosition);
float attenuation = distanceAttenuation(light, toLight);
float3 L = normalize(toLight);
float3 H = normalize(L + V);
diffuseFactor = attenuation * saturate(dot(N, L));
specularFactor = attenuation * powr(saturate(dot(N, H)), specularExponent);
break;
}

Point Lights in Action

To demonstrate our new point lights, we add a few of them to our scene:

let orbitLight0 = Light()
orbitLight0.color = SIMD3<Float>(0.45, 0, 0.95)
orbitLight0.intensity = 0.8
orbitLight0.type = .omni
let orbitLight1 = Light()
orbitLight1.color = SIMD3<Float>(0.95, 0.45, 0)
orbitLight1.intensity = 0.8
orbitLight1.type = .omni
let orbitLight2 = Light()
orbitLight2.color = SIMD3<Float>(0, 0.95, 0.45)
orbitLight2.intensity = 0.8
orbitLight2.type = .omni
orbitLights = [orbitLight0, orbitLight1, orbitLight2]

We then animate their positions around our Spot the cow model to demonstrate fully dynamic point lighting:

let orbitRadius: Float = 1.0
for i in 0..<orbitLights.count {
let lightAngleOffset = ((2 * Float.pi) /
Float(orbitLights.count)) * Float(i)
let lightAngle = t + lightAngleOffset
let orbitLightTransform =
simd_float4x4(translate:
SIMD3<Float>(orbitRadius * cos(lightAngle),
1.0,
orbitRadius * sin(lightAngle)))
orbitLights[i].worldTransform = orbitLightTransform
}

With these changes made, we can run the sample app and see our orbiting point lights in action:

Next time, we will turn our attention to efficiently rendering many objects with GPU instancing.

Warren Moore

Real-time graphics engineer based in San Francisco, CA.